Цель: \ Исследовать рынок общественного питания Москвы, для выбора места будущего проекта инвесторов.
Задачи:
# подключаю библиотеки
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
import seaborn as sns
import plotly.express as px
from plotly import graph_objects as go
import json
import folium
from folium import Map, Choropleth, Marker, plugins
from folium.plugins import MarkerCluster
from folium.plugins.heat_map import HeatMap
from folium.features import CustomIcon
import warnings
warnings.filterwarnings('ignore')
# создаю функцию для просмотра датасета
def first_view(x):
print('-' * 50, '\n', 'Исходный датафрейм:', '\n', '-'*50)
display(x.head())
print('-' * 50, '\n', 'Общая информация о датафрейме:', '\n', '-'*50)
display(x.info())
print('-' * 50, '\n', 'Количество пустых значений в датафрейме:', '\n', '-'*50)
display(x.isna().sum())
print('-' * 50,'\n','Количество явных дубликатов в датафрейме:','\n','-'*50)
display(x.duplicated().sum())
print('-' * 50,'\n','Названия столбцов:','\n','-'*50)
display(x.columns)
# создаю функцию для акронимов округов
def make_acronym(phrase):
phrase = phrase.replace('-', ' ').split()
acronym = ""
for word in phrase:
acronym = acronym + word[0].upper()
return acronym
# создаю функцию для визуализации фоновой картограммы через бибилиотеку plotly
def choropleth_mapbox(df, color, locations, geojson, labels,
featureidkey='properties.name',
center={'lat': 55.751244, 'lon': 37.618423},
color_continuous_scale='YlGnBu',
range_color=(450, 1000),
zoom=9, opacity=0.7, height=500):
fig = px.choropleth_mapbox(df,
color=color,
locations=locations,
geojson=geojson,
labels=labels,
featureidkey=featureidkey,
center=center,
color_continuous_scale=color_continuous_scale,
range_color=range_color,
zoom=zoom, opacity=opacity
)
fig.update_geos(fitbounds='locations')
fig.update_layout(
margin={'r': 0, 't': 0, 'l': 0, 'b': 0},
height=height,
mapbox_style='carto-positron'
)
return fig
Применю функцию с набором методов для просмотра сводной информации.
# отображение всех колонок и строк таблиц
pd.set_option('display.max_columns', None)
pd.set_option('display.max_rows', None)
# ширина колонок при выводе
pd.set_option('display.max_colwidth', 1000)
# загружаю датасет
data = pd.read_csv('moscow_places.csv')
# прочитаю файл в JSON-формате и сохраню в переменную
with open('admin_level_geomap.geojson', 'r', encoding='utf8') as f:
geo_json = json.load(f)
first_view(data)
-------------------------------------------------- Исходный датафрейм: --------------------------------------------------
| name | category | address | district | hours | lat | lng | rating | price | avg_bill | middle_avg_bill | middle_coffee_cup | chain | seats | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | WoWфли | кафе | Москва, улица Дыбенко, 7/1 | Северный административный округ | ежедневно, 10:00–22:00 | 55.878494 | 37.478860 | 5.0 | NaN | NaN | NaN | NaN | 0 | NaN |
| 1 | Четыре комнаты | ресторан | Москва, улица Дыбенко, 36, корп. 1 | Северный административный округ | ежедневно, 10:00–22:00 | 55.875801 | 37.484479 | 4.5 | выше среднего | Средний счёт:1500–1600 ₽ | 1550.0 | NaN | 0 | 4.0 |
| 2 | Хазри | кафе | Москва, Клязьминская улица, 15 | Северный административный округ | пн-чт 11:00–02:00; пт,сб 11:00–05:00; вс 11:00–02:00 | 55.889146 | 37.525901 | 4.6 | средние | Средний счёт:от 1000 ₽ | 1000.0 | NaN | 0 | 45.0 |
| 3 | Dormouse Coffee Shop | кофейня | Москва, улица Маршала Федоренко, 12 | Северный административный округ | ежедневно, 09:00–22:00 | 55.881608 | 37.488860 | 5.0 | NaN | Цена чашки капучино:155–185 ₽ | NaN | 170.0 | 0 | NaN |
| 4 | Иль Марко | пиццерия | Москва, Правобережная улица, 1Б | Северный административный округ | ежедневно, 10:00–22:00 | 55.881166 | 37.449357 | 5.0 | средние | Средний счёт:400–600 ₽ | 500.0 | NaN | 1 | 148.0 |
-------------------------------------------------- Общая информация о датафрейме: -------------------------------------------------- <class 'pandas.core.frame.DataFrame'> RangeIndex: 8406 entries, 0 to 8405 Data columns (total 14 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 name 8406 non-null object 1 category 8406 non-null object 2 address 8406 non-null object 3 district 8406 non-null object 4 hours 7870 non-null object 5 lat 8406 non-null float64 6 lng 8406 non-null float64 7 rating 8406 non-null float64 8 price 3315 non-null object 9 avg_bill 3816 non-null object 10 middle_avg_bill 3149 non-null float64 11 middle_coffee_cup 535 non-null float64 12 chain 8406 non-null int64 13 seats 4795 non-null float64 dtypes: float64(6), int64(1), object(7) memory usage: 919.5+ KB
None
-------------------------------------------------- Количество пустых значений в датафрейме: --------------------------------------------------
name 0 category 0 address 0 district 0 hours 536 lat 0 lng 0 rating 0 price 5091 avg_bill 4590 middle_avg_bill 5257 middle_coffee_cup 7871 chain 0 seats 3611 dtype: int64
-------------------------------------------------- Количество явных дубликатов в датафрейме: --------------------------------------------------
0
-------------------------------------------------- Названия столбцов: --------------------------------------------------
Index(['name', 'category', 'address', 'district', 'hours', 'lat', 'lng',
'rating', 'price', 'avg_bill', 'middle_avg_bill', 'middle_coffee_cup',
'chain', 'seats'],
dtype='object')
Вывод: \ Датафрейм состооит из 14 столбцов и 8 406 строк. \ В датасете выявлены пропуски в ряде столбцов:
hours - пропуски в количестве 536;price - пропуски в количестве 5 091;avg_bill - пропуски в количестве 4 590;middle_avg_bill - пропуски в количестве 5 257;middle_coffee_cup - пропуски в количестве 7 871;seats - пропуски в количестве 3 611.Работу по замене пропусков буду производить на этапе предобработки. Количество явных дубликатов - 0. Названия столбцов не требуют преобразования.
Проверю на наличие неявных дубликатов в столбце name и сразу же приведу их к нижнему регистру.
print('Количество уникальных названий заведений в текущем регистре:',
data['name'].nunique())
data['name'] = data['name'].str.lower()
print('Количество уникальных названий заведений после изменения на нижний регистр:',
data['name'].nunique())
print('Количество явных дубликатов после изменения регистра названия заведения:',
data.duplicated().sum())
Количество уникальных названий заведений в текущем регистре: 5614 Количество уникальных названий заведений после изменения на нижний регистр: 5512 Количество явных дубликатов после изменения регистра названия заведения: 0
Посмотрю, есть ли неявные дубликаты при комбинации столбцов name и address, а затем по меткам геоданных.
print('Количество неявных дубликатов по названию и адресу заведения:',
data[['name', 'address']].duplicated().sum())
print('Количество неявных дубликатов по геоданным:',
data[['lat', 'lng']].duplicated().sum())
Количество неявных дубликатов по названию и адресу заведения: 3 Количество неявных дубликатов по геоданным: 34
Дубликаты по столбцам name и address сохраню в список, чтобы проверить, действительно ли это ошибки в датасете или это представители сетевых заведений.
dup_name = list(data[data[['name', 'address']].duplicated()]['name'])
print('Названия заведений с неявными дубликатами:', dup_name)
Названия заведений с неявными дубликатами: ['more poke', 'раковарня клешни и хвосты', 'хлеб да выпечка']
dup_index = data.query('name in @dup_name').sort_values(by=['name', 'chain'])
dup_index[['name', 'address', 'chain']]
| name | address | chain | |
|---|---|---|---|
| 1430 | more poke | Москва, Волоколамское шоссе, 11, стр. 2 | 0 |
| 1511 | more poke | Москва, Волоколамское шоссе, 11, стр. 2 | 1 |
| 6088 | more poke | Москва, Духовской переулок, 19 | 1 |
| 2211 | раковарня клешни и хвосты | Москва, проспект Мира, 118 | 0 |
| 2420 | раковарня клешни и хвосты | Москва, проспект Мира, 118 | 1 |
| 7270 | раковарня клешни и хвосты | Москва, Братиславская улица, 12 | 1 |
| 3109 | хлеб да выпечка | Москва, Ярцевская улица, 19 | 0 |
| 3091 | хлеб да выпечка | Москва, Ярцевская улица, 19 | 1 |
| 7937 | хлеб да выпечка | Москва, Каширское шоссе, 61Г | 1 |
dup_index = dup_index[dup_index['chain'] == 0].index.to_list()
print('Индексы дубликатов:', dup_index)
Индексы дубликатов: [1430, 2211, 3109]
Действительно, в данных выявлены неявные дубликаты, которые нужно удалить. Исключать из датасета буду строки с данными, где в графе сетевое заведение или нет указан 0, так как эти данные являются ложными.
for i in dup_index:
data = data.drop(index=[i])
print('Количество неявных дубликатов:',
data[data[['name', 'address']].duplicated()]['name'].sum())
Количество неявных дубликатов: 0
data[data[['lat', 'lng']].duplicated(keep=False)].sort_values(by='lat').head(6)
| name | category | address | district | hours | lat | lng | rating | price | avg_bill | middle_avg_bill | middle_coffee_cup | chain | seats | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 7640 | крошка картошка | быстрое питание | Москва, Новоясеневский проспект, 7 | Юго-Западный административный округ | ежедневно, 10:00–22:00 | 55.607489 | 37.532367 | 4.1 | NaN | NaN | NaN | NaN | 1 | 60.0 |
| 7638 | кофешефф | ресторан | Москва, Новоясеневский проспект, 7 | Юго-Западный административный округ | ежедневно, 08:30–21:00 | 55.607489 | 37.532367 | 4.3 | NaN | NaN | NaN | NaN | 0 | 60.0 |
| 7787 | тануки | ресторан | Москва, Липецкая улица, 2, корп. 8 | Южный административный округ | ежедневно, 10:00–05:00 | 55.608307 | 37.664941 | 4.3 | выше среднего | Средний счёт:1000–1500 ₽ | 1250.0 | NaN | 1 | 120.0 |
| 7781 | ёрш | пиццерия | Москва, Липецкая улица, 2, корп. 8 | Южный административный округ | ежедневно, 11:30–05:00 | 55.608307 | 37.664941 | 4.4 | выше среднего | Средний счёт:1000–1500 ₽ | 1250.0 | NaN | 1 | 120.0 |
| 7767 | за обе щёки | кафе | Москва, Варшавское шоссе, вл132/2 | Южный административный округ | ежедневно, 09:00–21:00 | 55.620316 | 37.608922 | 3.6 | NaN | NaN | NaN | NaN | 1 | NaN |
| 7660 | 100лоффка | столовая | Москва, Варшавское шоссе, вл132/2 | Южный административный округ | пн-пт 09:00–17:30 | 55.620316 | 37.608922 | 4.4 | низкие | Средний счёт:100–270 ₽ | 185.0 | NaN | 0 | NaN |
По визуальной проверке совпадений по геоданным видно, что все заведения разные, не смотря на одинаковые координаты. Вероятно, произошел сбой при сборе или ошибка при определении локации.
Пропущенные значения в столбцах price, avg_bill, middle_avg_bill, middle_coffee_cup, seats заполнить корректно не представляется возможным. Оставлю как есть. Тип данных в столбце seats логично преобразовать в int, но так как он содержит пропуски это действие не смогу провести.
data['street'] = data['address'].str.split(pat=",", expand=True)[1]
data['is_24_7'] = data['hours'].apply(lambda x: True if x == 'ежедневно, круглосуточно' else False)
data['district_acronym'] = data['district'].apply(make_acronym)
data[['street', 'is_24_7', 'district_acronym']].head(11)
| street | is_24_7 | district_acronym | |
|---|---|---|---|
| 0 | улица Дыбенко | False | САО |
| 1 | улица Дыбенко | False | САО |
| 2 | Клязьминская улица | False | САО |
| 3 | улица Маршала Федоренко | False | САО |
| 4 | Правобережная улица | False | САО |
| 5 | Ижорская улица | False | САО |
| 6 | Клязьминская улица | False | САО |
| 7 | Клязьминская улица | False | САО |
| 8 | Дмитровское шоссе | False | САО |
| 9 | Ангарская улица | False | САО |
| 10 | Левобережная улица | True | САО |
Добавил новый столбец street с названиями улиц из столбца address, столбец is_24/7 с обозначением, что заведение работает ежедневно и круглосуточно (24/7) со значениями True — если заведение работает ежедневно и круглосуточно, False — в противоположном случае, и столбец district_acronym — для удобства вывода названий административных округов и визуализации в формате акронима.
Вывод:
Исследую количество объектов общественного питания Москвы по категориям и построю визуализацию методом столбчатой диаграммы.
# подготавливаю датасет для визуализации
data_category = data.groupby('category').agg(total_counts=('category', 'count'))
data_category.sort_values(by='total_counts', ascending=False, inplace=True)
# строю визуализацию
plt.figure(figsize=(14, 6))
ax = sns.barplot(
x=data_category.index,
y='total_counts',
data=data_category
)
plt.xticks(rotation=30, size=12)
# добавляю через цикл в каждый из столбцов его значение
for p in ax.patches:
ax.annotate(
format(int(p.get_height()), ',').replace(',', ' '),
(p.get_x() + p.get_width() / 2., p.get_height()),
ha = 'center', va = 'center',
xytext = (0, -14), color='white',
textcoords = 'offset points', size=16)
# указываю названия графика и оси Х, вывожу на экран
plt.title('Количество объектов общественного питания по категориям', size=18)
plt.xlabel('Категория заведения', size=14)
ax.get_yaxis().set_visible(False)
sns.despine(left=True)
plt.show()
# сводная таблица по долям
display((data_category['total_counts'] / data_category['total_counts'].sum() * 100).round(2).to_frame().T)
| category | кафе | ресторан | кофейня | бар,паб | пиццерия | быстрое питание | столовая | булочная |
|---|---|---|---|---|---|---|---|---|
| total_counts | 28.29 | 24.29 | 16.82 | 9.1 | 7.53 | 7.18 | 3.75 | 3.05 |
Вывод: \
Категории заведений отличаются по количеству. Самым частым типом является кафе, немного уступает ресторан, третье место занимает кофейня. Самым редким типом заведения является булочная. Удивительно то, что категория быстрое питание не вошла в тройку лидеров, а находится в конце списка.
Выведу сводную информацию по значениям столбца seats в разбивке по категориям заведений и медианное значение количества посадочных мест среди всех категорий. Визуализирую через диаграммы размаха и столбчатую, и дополнительно построю тепловую карту с распределениями медианных значений категорий заведений по округам.
display(
data.groupby('category')['seats']
.describe()
.sort_values(by='count', ascending=False).T
)
| category | ресторан | кафе | кофейня | бар,паб | пиццерия | быстрое питание | столовая | булочная |
|---|---|---|---|---|---|---|---|---|
| count | 1268.000000 | 1217.000000 | 751.000000 | 468.000000 | 427.000000 | 349.000000 | 164.000000 | 148.000000 |
| mean | 121.869874 | 97.365653 | 111.199734 | 124.532051 | 94.496487 | 98.891117 | 99.750000 | 89.385135 |
| std | 123.838539 | 117.922464 | 127.837772 | 145.011574 | 112.282703 | 106.611739 | 122.951453 | 97.685844 |
| min | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 | 0.000000 |
| 25% | 48.000000 | 35.000000 | 40.000000 | 48.000000 | 30.000000 | 28.000000 | 40.000000 | 25.000000 |
| 50% | 86.000000 | 60.000000 | 80.000000 | 82.500000 | 55.000000 | 65.000000 | 75.500000 | 50.000000 |
| 75% | 150.000000 | 120.000000 | 144.000000 | 150.000000 | 120.000000 | 140.000000 | 117.000000 | 120.000000 |
| max | 1288.000000 | 1288.000000 | 1288.000000 | 1288.000000 | 1288.000000 | 1040.000000 | 1200.000000 | 625.000000 |
# строю визуализацию
plt.figure(figsize=(14, 6))
ax = sns.boxplot(x='seats', y='category', data=data)
plt.yticks(rotation=30, size=12)
plt.grid(axis='x')
# указываю названия графика и осей, вывожу на экран
plt.title('Диаграмма размаха по количество посадочных мест по категориям заведений', size=18)
plt.xlabel('Количеств мест', size=14)
plt.ylabel('Категория', size=14)
plt.show()
data[data['seats'] == 1288]['address'].unique()
array(['Москва, проспект Вернадского, 94, корп. 1',
'Москва, проспект Вернадского, 121, корп. 1',
'Москва, проспект Вернадского, 97, корп. 1',
'Москва, проспект Вернадского, 84, стр. 1',
'Москва, проспект Вернадского, 51, стр. 1',
'Москва, проспект Вернадского, 41, стр. 1'], dtype=object)
Посмотрел, где распологаются заведения с максимальным количеством мест (1288) - все на проспекте Вернадского.
# строю визуализацию
plt.figure(figsize=(14, 6))
ax = sns.boxplot(x='seats', y='category', data=data)
plt.yticks(rotation=30, size=12)
plt.grid(axis='x')
# увеличиваю масштаб
plt.xlim(-5, 350)
# обозначаю общее медианное значение
plt.axvline(x=data['seats'].median(), color='white', linestyle='--')
# указываю названия графика и осей, вывожу на экран
plt.title('Диаграмма размаха по количество посадочных мест по категориям заведений', size=18)
plt.xlabel('Количеств мест', size=14)
plt.ylabel('Категория', size=14)
plt.show()
# подготавливаю датасет для визуализации
data_seats = (
data.groupby('category')
.agg(avg_seats=('seats', 'median'))
.sort_values(by='avg_seats', ascending=False)
)
# строю визуализацию
plt.figure(figsize=(14, 6))
ax = sns.barplot(
x=data_seats.index,
y="avg_seats",
data=data_seats
)
plt.xticks(rotation=30, size=12)
# добавляю через цикл в каждый из столбцов его значение
for p in ax.patches:
ax.annotate(
format(int(p.get_height()), ',').replace(',', ' '),
(p.get_x() + p.get_width() / 2., p.get_height()),
ha = 'center', va = 'center',
xytext = (0, -14), color='white',
textcoords = 'offset points', size=16)
# указываю названия графика и оси Х, вывожу на экран
plt.title('Медианное значение количества посадочных мест по категориям заведений', size=18)
plt.xlabel('Категория заведения', size=14)
plt.ylabel('Количество посадочных мест', size=14)
sns.despine(left=True)
plt.show()
# подготавливаю датасет для визуализации
pvt_data_seats = data.pivot_table(
index='district_acronym', columns='category',
values='seats', aggfunc='median'
)
# строю визуализацию
fig, ax = plt.subplots(figsize=(11, 9))
sns.heatmap(
pvt_data_seats,
cmap='Reds', center=75, annot=True,
fmt=".1f", linewidths=.5, ax=ax)
# указываю название графика
ax.set_title('Медиа количества посадочных мест в каждой категории по районам', size=16)
plt.ylabel(' ')
plt.xlabel(' ')
plt.setp(ax.get_xticklabels(), rotation=30)
plt.setp(ax.get_yticklabels(), rotation=0)
# вывожу на экран
fig.tight_layout()
plt.show()
print(
'Медианное значение количества посадочных мест среди всех категорий заведений: {:.0f}'
.format(data['seats'].median())
)
Медианное значение количества посадочных мест среди всех категорий заведений: 75
Вывод: \
На диаграммах размаха видно, что по всем категориям наблюдаются выбросы. Возможно, что в выборку попали заведения, которые располагаются на фуд-кортах торговых центрах и в качестве своей вместимости указали вместимость общую. \
Медианное значение для всех составляет 75 мест. Больше этого числа имеют ресторан (86), кофейня (80) и бар.паб (82). Самая маленькая медиана мест - в булочной (50). Среди округов выше медианного значений почти во всех категориях у ЦАО (7 из 8), а у ВАО, САО и ЮЗАО только по одной из категорий заведений медиана превышена.
Рассмотрю и изображу соотношение сетевых и несетевых заведений в датасете. Для визуализации использую круговую и столбчатую диаграммы с разбивкой на два критерия.
# строю и вывожу на экран визуализацию
fig = go.Figure(
data = go.Pie(labels = data['chain'].value_counts().reset_index(),
values = data['chain'].value_counts()),
layout = go.Layout(title = go.layout.Title(text = \
"Соотношение сетевых и несетевых заведений"),
height = 500, width = 800, title_x=0.5)
)
for trace in fig.data:
trace['labels'] = ['Несетевые', 'Сетевые']
fig.show()
Почти две трети являются несетевыми заведениям. Посмотрю, как по категориям делится это значение.
# строю визуализацию
plt.figure(figsize=(14, 6))
ax = sns.histplot(
data=data, x='category',
hue='chain', multiple='dodge',
palette='Set1', shrink=.9
)
plt.grid(axis='y')
ax.legend(['Сетевые заведения', 'Несетевые заведения'])
plt.xticks(rotation=30, size=12)
# добавляю через цикл в каждый из столбцов его значение
for p in ax.patches:
ax.annotate(
format(int(p.get_height()), ',').replace(',', ' '),
(p.get_x() + p.get_width() / 2., p.get_height()),
ha = 'center', va = 'center',
xytext = (0, -10), color='white',
textcoords = 'offset points', size=12)
# указываю названия графика и осей, вывожу на экран
plt.title('Количество сетевых и несетевых заведений общественного питания по категориям', size=17)
plt.ylabel('Количество', size=14)
plt.xlabel('Категория', size=14)
plt.show()
data_chain = (data.groupby('category')
.agg(chain=('chain', 'sum'),
total=('chain', 'count'))
)
data_chain['part'] = (data_chain['chain'] / data_chain['total'] * 100).round(2)
data_chain.sort_values(by='part', ascending=False)
| chain | total | part | |
|---|---|---|---|
| category | |||
| булочная | 157 | 256 | 61.33 |
| пиццерия | 330 | 633 | 52.13 |
| кофейня | 720 | 1413 | 50.96 |
| быстрое питание | 232 | 603 | 38.47 |
| ресторан | 730 | 2041 | 35.77 |
| кафе | 779 | 2377 | 32.77 |
| столовая | 88 | 315 | 27.94 |
| бар,паб | 169 | 765 | 22.09 |
Вывод: \ По данным есть большое разделение на сетевые (38.1%) и несетевые (61.9%) заведения по категориям.\ Лидеры по доле сетей:
булочная;кофейня и пиццерия;быстрое питание и ресторан.Лидеры по количеству сетевых заведений: кафе (779), ресторан (730), кофейня (720).
Сгруппирую данные по названиям заведений и найду топ-15 популярных сетей в Москве.
data_top15 = (data[data['chain'] == 1]
.groupby(['name', 'category'])
.agg(count_name=('name', 'count'))
.sort_values(by='count_name', ascending=False)
.reset_index().head(15)
.sort_values(by='category')
)
display(data_top15.sort_values(by='count_name', ascending=False))
| name | category | count_name | |
|---|---|---|---|
| 0 | шоколадница | кофейня | 119 |
| 1 | домино'с пицца | пиццерия | 76 |
| 2 | додо пицца | пиццерия | 74 |
| 3 | one price coffee | кофейня | 71 |
| 4 | яндекс лавка | ресторан | 69 |
| 5 | cofix | кофейня | 65 |
| 6 | prime | ресторан | 49 |
| 7 | кофепорт | кофейня | 42 |
| 8 | кулинарная лавка братьев караваевых | кафе | 39 |
| 9 | теремок | ресторан | 36 |
| 10 | cofefest | кофейня | 31 |
| 11 | чайхана | кафе | 26 |
| 12 | буханка | булочная | 25 |
| 13 | drive café | кафе | 24 |
| 14 | кофемания | кофейня | 22 |
В TOP-15 попали преимущественно сети, которые реализуют готовую продукцию (с витрины) или в их меню доминируют быстро приготавливаемые блюда / стритфуд. Основной напиток в них - кофе.
Построю визуализацию через круговую и столбчатую диаграммы.
# подготавливаю датасет для визуализации
list_category = data_top15.groupby('category')['count_name'].sum()
# установливаю размер и цвета
plt.figure(figsize=(10, 10))
colors_small = ['#4285f4', '#ea4335', '#fbbc05', '#34a853', '#8561c5']
colors_big = ['#679df6',
'#ee685d', '#f07b71', '#f28e85',
'#fbc936', '#fcd050', '#fcd669', '#fddd82', '#fde49b', '#fdeab4',
'#5cb975', '#70c286',
'#9475cc', '#a388d3', '#b39cdb']
# строю визуализацию
bigger = plt.pie(data_top15['count_name'],
labels=data_top15['name'],
colors=colors_big,
startangle=90, frame=True,
autopct='%1.f%%', pctdistance=0.85
)
smaller = plt.pie(list_category, colors=colors_small,
radius=0.70, startangle=90,
autopct='%1.f%%', pctdistance=0.75
)
centre_circle = plt.Circle((0, 0), 0.4, color='white', linewidth=0)
# настрою легенду
leg = plt.legend(loc = 'upper right', labels=list_category.index, fontsize=12)
leg.legendHandles[0].set_color('#4285f4')
leg.legendHandles[1].set_color('#ea4335')
leg.legendHandles[2].set_color('#fbbc05')
leg.legendHandles[3].set_color('#34a853')
leg.legendHandles[4].set_color('#8561c5')
# указываю название графика, вывожу на экран
fig = plt.gcf()
fig.gca().add_artist(centre_circle)
plt.title('TOP-15 популярных сетей в Москве по категориям', size=18)
plt.show()
# строю визуализацию
plt.figure(figsize=(14, 8))
ax = sns.barplot(
x='count_name',
y='name',
hue='category',
data=data_top15.sort_values(by='count_name', ascending=False),
palette='Set1',
dodge=False
)
# указываю названия графика и оси Х, вывожу на экран
plt.xlabel('Количество заведений', size=14)
plt.ylabel(' ')
plt.grid(axis='x')
ax.legend(title='Категория')
sns.despine(left=True)
plt.show()
Вывод: \ По результатам анализа TOP-15 самых популярных заведений вижу, что:
кофейня (46%), такой отрыв может быть обусловлен огромной популярностью услуги кофе навынос;пиццерия и ресторан;булочная с долей в 3%.Все эти сети заведений объединяет два фактора:
Посмотрю, какие административные районы Москвы присутствуют в датасете, далее отображу общее количество заведений и количество заведений каждой категории по районам.
data_district = data.pivot_table(
index='district_acronym', columns='category',
values='name', aggfunc='count'
)
data_district['total_district'] = data_district.sum(axis=1)
data_district['total_district_part'] = (
data_district['total_district'] /
data_district['total_district']
.sum() * 100).round(2)
data_district = data_district.sort_values(by='total_district', ascending=False).reset_index()
data_district
| category | district_acronym | бар,паб | булочная | быстрое питание | кафе | кофейня | пиццерия | ресторан | столовая | total_district | total_district_part |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | ЦАО | 364 | 50 | 87 | 464 | 428 | 113 | 670 | 66 | 2242 | 26.68 |
| 1 | САО | 68 | 39 | 58 | 235 | 193 | 77 | 188 | 41 | 899 | 10.70 |
| 2 | ЮАО | 68 | 25 | 85 | 264 | 131 | 73 | 202 | 44 | 892 | 10.62 |
| 3 | СВАО | 63 | 28 | 82 | 269 | 159 | 68 | 181 | 40 | 890 | 10.59 |
| 4 | ЗАО | 50 | 37 | 62 | 238 | 150 | 71 | 218 | 24 | 850 | 10.12 |
| 5 | ВАО | 53 | 25 | 71 | 272 | 105 | 72 | 160 | 40 | 798 | 9.50 |
| 6 | ЮВАО | 38 | 13 | 67 | 282 | 89 | 55 | 145 | 25 | 714 | 8.50 |
| 7 | ЮЗАО | 38 | 27 | 61 | 238 | 96 | 64 | 168 | 17 | 709 | 8.44 |
| 8 | СЗАО | 23 | 12 | 30 | 115 | 62 | 40 | 109 | 18 | 409 | 4.87 |
Сформировал сводную таблицу по районам с рабивкой ко категориям заведений, добавил столбцы о суммарном количестве и доле от общего числа. Попробую визуализировать через хитмэп и столбчатую диаграмму с накоплением.
# строю визуализацию
fig, ax = plt.subplots(figsize=(11, 9))
sns.heatmap(
data_district.set_index('district_acronym')
.drop(['total_district', 'total_district_part'], axis=1),
cmap='Reds', center=175, annot=True,
fmt="d", linewidths=.5, ax=ax)
# указываю название графика
ax.set_title('Количество заведений каждой категории по районам', size=16)
plt.ylabel(' ')
plt.xlabel(' ')
plt.setp(ax.get_xticklabels(), rotation=30)
plt.setp(ax.get_yticklabels(), rotation=0)
# вывожу на экран
fig.tight_layout()
plt.show()
# подготавливаю датасет для визуализации
data_district_bar = (
data.groupby(['district_acronym', 'category'], as_index=False)
.agg(counts=('name', 'count'))
.merge(data_district[['district_acronym', 'total_district']],
how='left', on='district_acronym')
.sort_values(by='total_district')
)
# строю визуализацию и вывожу на экран
fig = px.bar(data_district_bar, x='counts', y='district_acronym',
color='category', height=500,
labels={'category':'Тип заведения',
'counts' : 'Количество',
'district_acronym' : 'Округ'},
title='Количество заведений каждой категории по районам')
fig.update_layout(title_x=0.5)
fig.show()
Вывод: \
По количеству заведений во всех категориях лидиром является Центральный административный округ (ЦАО). Его удельный вес от общего числа составил 26.68%, что в 2.5 раза более чем у ближайшего района. Такая цифра вполне закономерна, так как центр города является местом сосредоточения бизнеса и объектов туризма, что генерирует трафик для мест общественного питания. Он отличается и по структуре распределения заведений: только в нем доля ресторан больше, чем в других округах, где преобладают кафе. Схожи все районы только в том, что количество булочная самое небольшое из всех категорий. Меньше всего заведений находится в Северо-Западном округе.
Посчитаю и визуализирую через столбчатую диаграмму распределение средних рейтингов по категориям заведений.
# подготавливаю датасет для визуализации
data_avg_rat = (
data
.groupby('category').agg(avg_rating=('rating', 'mean'))
.round(2).sort_values(by='avg_rating', ascending=False)
)
# строю визуализацию
plt.figure(figsize=(14, 6))
ax = sns.barplot(data=data_avg_rat,
x=data_avg_rat.index,
y="avg_rating")
plt.xticks(rotation=30, size=12)
# добавляю через цикл в каждый из столбцов его значение
for p in ax.patches:
ax.annotate(
format(float(p.get_height()), ',').replace(',', ' '),
(p.get_x() + p.get_width() / 2., p.get_height()),
ha = 'center', va = 'center',
xytext = (0, -12), color='white',
textcoords = 'offset points', size=16)
# указываю названия графика и осей
plt.title('Средний рейтинг по категориям заведений', size=18)
plt.xlabel('Категория заведения', size=14)
plt.ylabel('Средний рейтинг', size=14)
# обозначаю среднее значение
plt.axhline(y=data_avg_rat['avg_rating'].mean(), color='black', linestyle='--')
# увеличиваю масштаб и вывожу на экран
plt.ylim(3.9, 4.45)
plt.grid(axis='y')
sns.despine(left=True)
plt.show()
print('Средний рейтинг среди всех категорий:', data_avg_rat['avg_rating'].mean().round(2))
Средний рейтинг среди всех категорий: 4.24
Вывод: \
Наиболее высокие оценки получает категория бар.паб, а самые низкие - заведения быстрое питание. Стоит отметить, что средний рейтинг в большинстве категорий находятся в близком к друг другу значениях. Западения наблюдаются только в кафе и быстрое питание.
Построю фоновую картограмму для более наглядной визуализации среднего рейтинга заведений по районам b отмечу все ззаведения на карте.
category_avg_rat = (
data.groupby('district', as_index=False)['rating']
.agg('mean').round(2)
)
category_avg_rat.sort_values(by='rating', ascending=False)
| district | rating | |
|---|---|---|
| 5 | Центральный административный округ | 4.38 |
| 2 | Северный административный округ | 4.24 |
| 4 | Северо-Западный административный округ | 4.21 |
| 1 | Западный административный округ | 4.18 |
| 8 | Южный административный округ | 4.18 |
| 0 | Восточный административный округ | 4.17 |
| 7 | Юго-Западный административный округ | 4.17 |
| 3 | Северо-Восточный административный округ | 4.15 |
| 6 | Юго-Восточный административный округ | 4.10 |
m = Map(location=[55.751244, 37.618423],
zoom_start=10)
# создаю фоновую картограмму и добавляю ее на карту
choropleth = Choropleth(
geo_data=geo_json,
data=category_avg_rat,
columns=['district', 'rating'],
key_on='feature.name',
fill_color='YlGnBu',
fill_opacity=0.8,
legend_name='Средний рейтинг заведений по районам',
).add_to(m)
# создаю маркеры по названиям районов
choropleth.geojson.add_child(
folium.features.GeoJsonTooltip(
fields = ['ref'], aliases = ['Район:'],
labels = True, localize = True, sticky = False)
)
# вывожу карту
m
Построю тепловую карту с распределением внутри районов.
m = Map(location=[55.751244, 37.618423], zoom_start=10)
m.add_child(plugins.HeatMap(data[['lat','lng']], radius=14))
Вывод: \ Центральный административный округ лидирует не только по количеству, но и по среднему рейтингу заведений. Второе место обоих расчетов занимает Северный административный округ. На 3-е место списка попал район с наименьшей концентрацией мест общественного питания - Северо-Западный административный округ.
Выведу TOP-15 улиц по количеству заведений на них и развибкой по категориям. Далее проиллюстрирую через интерактивную столбчатую диаграмму.
total_counts_str = data['street'].value_counts().reset_index()
total_counts_str.columns = ['street', 'counts_per_str']
top_str_list = total_counts_str.nlargest(15, columns='counts_per_str')
top_str_list
| street | counts_per_str | |
|---|---|---|
| 0 | проспект Мира | 183 |
| 1 | Профсоюзная улица | 122 |
| 2 | проспект Вернадского | 108 |
| 3 | Ленинский проспект | 107 |
| 4 | Ленинградский проспект | 95 |
| 5 | Дмитровское шоссе | 88 |
| 6 | Каширское шоссе | 77 |
| 7 | Варшавское шоссе | 76 |
| 8 | Ленинградское шоссе | 70 |
| 9 | МКАД | 65 |
| 10 | Люблинская улица | 60 |
| 11 | улица Вавилова | 55 |
| 12 | Кутузовский проспект | 54 |
| 13 | улица Миклухо-Маклая | 49 |
| 14 | Пятницкая улица | 48 |
# подготавливаю датасет для визуализации
top_str = top_str_list['street']
data_top15_str = (
data.query("street in @top_str")
.groupby(['street', 'category'], as_index=False)
.agg(cat_per_str=('category', 'count'))
)
data_top15_str = (
data_top15_str.merge(top_str_list, how='left', on='street')
.sort_values(by='counts_per_str')
)
# строю визуализацию и вывожу на экран
fig = px.bar(data_top15_str, x='cat_per_str', y='street',
color='category', height=500,
labels={'category':'Тип заведения',
'cat_per_str' : 'Количество',
'street' : 'Улица'},
title='TOP-15 улиц по количеству заведений и их категории')
fig.update_layout(title_x=0.5)
fig.show()
# сводная таблица по долям
display((top_str_list['counts_per_str'] / data['street'].count() * 100).round(2).to_frame().T)
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| counts_per_str | 2.18 | 1.45 | 1.29 | 1.27 | 1.13 | 1.05 | 0.92 | 0.9 | 0.83 | 0.77 | 0.71 | 0.65 | 0.64 | 0.58 | 0.57 |
Вывод: \ По данным графика и сводной таблицы видно:
кафе, кофейня и ресторан;Найду улицы на которых только одно заведение общественного питания и визуализирую через столбчатую диаграмму.
# подготавливаю датасет для визуализации
one_count_str = total_counts_str[total_counts_str['counts_per_str'] == 1]['street']
data_one_count_str = (
data.query("street in @one_count_str")
.groupby('category')
.agg(total_counts=('category', 'count'))
.sort_values(by='total_counts', ascending=False)
)
# строю визуализацию
plt.figure(figsize=(14, 6))
ax = sns.barplot(
x=data_one_count_str.index,
y="total_counts",
data=data_one_count_str
)
plt.xticks(rotation=30, size=12)
# добавляю через цикл в каждый из столбцов его значение
for p in ax.patches:
ax.annotate(
format(int(p.get_height()), ',').replace(',', ' '),
(p.get_x() + p.get_width() / 2., p.get_height()),
ha = 'center', va = 'center',
xytext = (0, -10), color='white',
textcoords = 'offset points', size=12)
# указываю названия графика и оси Х
plt.title('Улицы с одним заведением по категориям', size=18)
plt.xlabel('Категория заведения', size=14)
# вывожу на экран
ax.get_yaxis().set_visible(False)
sns.despine(left=True)
plt.show()
# сопровождаю график сводной инофрмацией
print('Количество улиц с одним заведением:', len(one_count_str))
print('Доля улиц с одним заведением в датасете: {:.2%}'
.format(len(one_count_str) / data['street'].nunique()))
print('Доля каждого типа заведений среди улиц с одним объектом общепита:')
(data_one_count_str['total_counts'] / data_one_count_str['total_counts'].sum() * 100).round(2).to_frame().T
Количество улиц с одним заведением: 458 Доля улиц с одним заведением в датасете: 31.63% Доля каждого типа заведений среди улиц с одним объектом общепита:
| category | кафе | ресторан | кофейня | бар,паб | столовая | быстрое питание | пиццерия | булочная |
|---|---|---|---|---|---|---|---|---|
| total_counts | 34.93 | 20.31 | 18.34 | 8.52 | 7.86 | 5.02 | 3.28 | 1.75 |
data_distr_one_count_str = (
data.query('street in @one_count_str')
.groupby('district_acronym')
.agg(counts=('name', 'count'))
.sort_values(by='counts', ascending=False)
)
data_distr_one_count_str['rate'] = (
data_distr_one_count_str['counts'] /
data_distr_one_count_str['counts'].sum() * 100).round(2)
# строю визуализацию
plt.figure(figsize=(14, 6))
ax = sns.barplot(
x=data_distr_one_count_str.index,
y='counts',
data=data_distr_one_count_str
)
plt.xticks(rotation=30, size=12)
# добавляю через цикл в каждый из столбцов его значение
for p in ax.patches:
ax.annotate(
format(int(p.get_height()), ',').replace(',', ' '),
(p.get_x() + p.get_width() / 2., p.get_height()),
ha = 'center', va = 'center',
xytext = (0, -10), color='white',
textcoords = 'offset points', size=12)
# указываю названия графика и оси Х
plt.title('Улицы с одним заведением по категориям', size=18)
plt.xlabel('Округ', size=14)
# вывожу на экран
ax.get_yaxis().set_visible(False)
sns.despine(left=True)
plt.show()
data_distr_one_count_str.T
| district_acronym | ЦАО | СВАО | ВАО | САО | ЮАО | ЮВАО | ЗАО | СЗАО | ЮЗАО |
|---|---|---|---|---|---|---|---|---|---|
| counts | 145.00 | 55.00 | 52.00 | 52.00 | 43.00 | 39.00 | 35.00 | 19.00 | 18.00 |
| rate | 31.66 | 12.01 | 11.35 | 11.35 | 9.39 | 8.52 | 7.64 | 4.15 | 3.93 |
Вывод: \
В датасете преставлено 458 улиц с одним заведением общественного питания, что составляет более 30% из всех уникальных значений. Тип заведения которое чаще других встречается - кафе (160, 34.93%), второе и третье места заняли улицы где есть по одному заведению ресторан или кофейня - в районе 20%. Самая не многочисленная категория не набирает и 2% - булочная. Более 31% из всех улиц расположились в Центральном административном округе - в этой части города большинство улиц имеют небольшую протяженность, так как формировались столетия назад.
Посчитаю медиану средних чеков для каждого района и построю фоновую картограмму (хороплет).
data_median_bill = (
data.groupby('district', as_index=False)
.agg(median_bill=('middle_avg_bill', 'median'))
.sort_values(by='median_bill', ascending=False)
)
data_median_bill
| district | median_bill | |
|---|---|---|
| 1 | Западный административный округ | 1000.0 |
| 5 | Центральный административный округ | 1000.0 |
| 4 | Северо-Западный административный округ | 700.0 |
| 2 | Северный административный округ | 650.0 |
| 7 | Юго-Западный административный округ | 600.0 |
| 0 | Восточный административный округ | 575.0 |
| 3 | Северо-Восточный административный округ | 500.0 |
| 8 | Южный административный округ | 500.0 |
| 6 | Юго-Восточный административный округ | 450.0 |
# применяю функцию для визуализации
fig = choropleth_mapbox(
df=data_median_bill,
color='median_bill',
locations='district',
geojson=geo_json,
labels={'district' : 'Район',
'median_bill' : 'Медианный чек'}
)
fig.show()
# создам сводную таблицу
display(
data.pivot_table(index='district', columns='category',
values='middle_avg_bill', aggfunc='median')
.reset_index()
.merge(data_median_bill, how='left', on='district')
.sort_values(by='median_bill', ascending=False)
)
| district | бар,паб | булочная | быстрое питание | кафе | кофейня | пиццерия | ресторан | столовая | median_bill | |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | Западный административный округ | 1250.0 | 600.0 | 367.5 | 625.0 | 600.0 | 700.0 | 1300.0 | 300.0 | 1000.0 |
| 5 | Центральный административный округ | 1250.0 | 962.5 | 450.0 | 700.0 | 500.0 | 1000.0 | 1250.0 | 300.0 | 1000.0 |
| 4 | Северо-Западный административный округ | 1000.0 | 200.0 | 275.0 | 650.0 | 325.0 | 549.5 | 1250.0 | 300.0 | 700.0 |
| 2 | Северный административный округ | 1250.0 | 625.0 | 300.0 | 550.0 | 325.0 | 650.0 | 1187.5 | 300.0 | 650.0 |
| 7 | Юго-Западный административный округ | 1000.0 | 500.0 | 375.0 | 450.0 | 375.0 | 500.0 | 1050.0 | 305.0 | 600.0 |
| 0 | Восточный административный округ | 1200.0 | 300.0 | 375.0 | 450.0 | 400.0 | 500.0 | 1000.0 | 300.0 | 575.0 |
| 3 | Северо-Восточный административный округ | 900.0 | 500.0 | 425.0 | 475.0 | 325.0 | 500.0 | 837.5 | 275.0 | 500.0 |
| 8 | Южный административный округ | 1175.0 | 437.5 | 400.0 | 600.0 | 387.5 | 500.0 | 975.0 | 282.5 | 500.0 |
| 6 | Юго-Восточный административный округ | 925.0 | 375.0 | 300.0 | 400.0 | 250.0 | 500.0 | 925.0 | 275.0 | 450.0 |
m = folium.Map([55.751244, 37.618423], zoom_start=10)
heatmap_data = data[['lat','lng', 'middle_avg_bill']].copy().dropna() # без удаления пустых значений heatmap не сработает
heatmap = heatmap_data[['lat','lng', 'middle_avg_bill']]
HeatMap(data=heatmap, radius=14).add_to(m)
m
Вывод: \
Самый высокий медианный чек в Центральном и Западном административных округах - 1000 рублей. На последнем месте расположился Юго-Восточный - 450 рублей. Если сравнить эти показатели с разбивкой по категория заведений, то наименьшая разница в столовая - не более 10% среди всех районов, в булочная наоборот самая высокий разброс почти 500%. Центральный административный округ почти во всех категориях имеет наивысший средний чек, кроме ресторан и кафе.
Исследую часы работы заведений и их зависимость от категории заведения и расположения. Визуализирую данные по категориям заведений через столбчатую диаграмму, а их расположение фоновой картограммой (хороплет).
# подготавливаю датасет для визуализации
data_around_the_clock = (
data.query('is_24_7 == True')
.groupby('category', as_index=False)
.agg(around_the_clock=('is_24_7', 'count'))
.sort_values(by='around_the_clock', ascending=False)
)
# строю визуализацию и вывожу на экран
fig = px.bar(data_around_the_clock, x='around_the_clock', y='category',
color='category', height=500,
labels={'category':'Тип заведения',
'around_the_clock' : 'Количество'},
title='Количество круглосуточных заведений по категориям')
fig.update_layout(title_x=0.5)
fig.show()
print('Доля круглосуточных заведений от общего числа: {:.2%}'.format(data['is_24_7'].sum() / len(data)))
data_around_the_clock
Доля круглосуточных заведений от общего числа: 8.69%
| category | around_the_clock | |
|---|---|---|
| 3 | кафе | 267 |
| 2 | быстрое питание | 150 |
| 6 | ресторан | 135 |
| 4 | кофейня | 59 |
| 0 | бар,паб | 52 |
| 5 | пиццерия | 31 |
| 1 | булочная | 24 |
| 7 | столовая | 12 |
Больше всего работают круглосуточно кафе - 267, второе и третье место с небольшой разницей делят быстрое питание и ресторан. В нижней части списка оказались - пиццерия, булочная, столовая - ассортимент их продукции пользуется высоким спросом в течении дня.
# применяю функцию для визуализации
fig = choropleth_mapbox(
df=(
data.query('is_24_7 == True')
.groupby('district', as_index=False)
.agg(around_the_clock=('is_24_7', 'count'))
),
color='around_the_clock',
locations='district',
geojson=geo_json,
labels={'district' : 'Район',
'around_the_clock' : 'Заведения 24/7'},
range_color=(40, 140)
)
fig.show()
# создаю сводную таблицу
display(
data.pivot_table(index='district', columns='category',
values='is_24_7', aggfunc='sum')
.reset_index()
.merge(data.groupby('district', as_index=False)['is_24_7'].sum(), how='left', on='district')
.sort_values(by='is_24_7', ascending=False)
)
| district | бар,паб | булочная | быстрое питание | кафе | кофейня | пиццерия | ресторан | столовая | is_24_7 | |
|---|---|---|---|---|---|---|---|---|---|---|
| 5 | Центральный административный округ | 29 | 1 | 14 | 30 | 26 | 2 | 26 | 3 | 131 |
| 0 | Восточный административный округ | 1 | 6 | 21 | 33 | 5 | 8 | 21 | 2 | 97 |
| 6 | Юго-Восточный административный округ | 5 | 1 | 15 | 48 | 1 | 9 | 13 | 1 | 93 |
| 3 | Северо-Восточный административный округ | 4 | 4 | 17 | 31 | 3 | 3 | 11 | 2 | 75 |
| 8 | Южный административный округ | 4 | 3 | 24 | 26 | 1 | 4 | 13 | 0 | 75 |
| 7 | Юго-Западный административный округ | 0 | 1 | 20 | 34 | 7 | 0 | 10 | 1 | 73 |
| 1 | Западный административный округ | 5 | 0 | 18 | 25 | 9 | 2 | 12 | 1 | 72 |
| 2 | Северный административный округ | 4 | 5 | 14 | 28 | 5 | 2 | 11 | 2 | 71 |
| 4 | Северо-Западный административный округ | 0 | 3 | 7 | 12 | 2 | 1 | 18 | 0 | 43 |
Вывод: \
Центральный административный округ и по этому показателю вышел в уверенные лидеры, в 4 из 8 категорий у него перевес, причем в бар.паб и ресторан имеет доминирующее число. Восточный и Юго-Восточный административные округа заняли 2 и 3 места с почти равными результатами. Замкнул рейтинг Северо-Западный район, а оставшиеся административные округа расположились с максимально близким интервалом между собой.
Исследую особенности заведений с плохими рейтингами, средние чеки в таких местах и распределение по категориям. Для отображения воспользуюсь столбчатыми диаграммами.
data['rating'].describe().to_frame().T
| count | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|
| rating | 8403.0 | 4.229894 | 0.470426 | 1.0 | 4.1 | 4.3 | 4.4 | 5.0 |
Проверил сводные значения рейтингов в датасете. Средний рейтинг составил 4.23, а медианный - 4.3. Такой поазатель можно считать достаточно высоким и хорошим, учитывая количество заведений. В качестве ориентира для определения границы плохой-хороший рейтинг возьму 25 перцентиль со значением 4.1.
# подготавливаю датасет для визуализации
data['bad_good_rating'] = (
data['rating'].apply(lambda x: 'good' if x >= 4.1 else 'bad')
)
bad_good_catering = (
data.groupby(['category', 'bad_good_rating'], as_index=False)
.agg(counts=('bad_good_rating','count'))
.merge(data_category, how='left',
left_on='category', right_on=data_category.index)
)
bad_good_catering['total_counts'] = (bad_good_catering['counts'] / bad_good_catering['total_counts'] * 100).round(2)
# строю визуализацию и вывожу на экран
fig = px.bar(bad_good_catering.sort_values(by='total_counts'),
x='total_counts', y='category', text='counts',
color='bad_good_rating', height=400,
labels={'category':'Тип заведения',
'total_counts' : 'Доля',
'bad_good_rating' : 'Тип рейтинга',
'counts' : 'Количество'},
title='Заведения с плохими и хорошими рейтингами по категориям')
fig.update_traces(texttemplate='%{text:.d}', textposition='inside')
fig.update_layout(title_x=0.5)
fig.show()
Если смотреть на количество заведений с плохим рейтингом, лидером является категория кафе (717 заведений, 30.16%). По удельному весу в группе хуже всех ситуация в быстрое питание (222 заведения, 36.82%). наименьшие шанс попасть в заведение с плохим рейтингом при посещении пиццерия или бар.паб.
# подготавливаю датасет для визуализации
bad_rating_mean_bills = (
data.query('bad_good_rating == "bad"')
.groupby(['category', 'district_acronym'], as_index=False)
.agg(mean_bills=('middle_avg_bill','mean'))
.sort_values(by='mean_bills', ascending=False)
)
# строю визуализацию и вывожу на экран
fig = px.bar(bad_rating_mean_bills, x='mean_bills', y='district_acronym',
color='category', height=500,
labels={'category':'Тип заведения',
'mean_bills' : 'Средний чек',
'district_acronym' : 'Район'},
title='Средний чек в заведениях с плохим рейтингом по районам и категориям')
fig.show()
# строю визуализацию
plt.figure(figsize=(14, 8))
ax = sns.barplot(
x='district_acronym',
y='mean_bills',
hue='category',
data=bad_rating_mean_bills,
palette='Set1'
)
# указываю названия графика и оси Х, вывожу на экран
plt.title('Средний чек в заведениях с плохим рейтингом по районам и категориям', size=18)
plt.xlabel('Районы', size=14)
plt.ylabel('Количество заведений', size=14)
plt.grid(axis='y')
ax.legend(title='Категория')
sns.despine(left=True)
plt.show()
Вывод: \
Самый высокий чек в заведении с рейтингом менее 4.1 будет при посещении категории быстрое питание причем не в Центральном районе Москвы, а в Восточном административном округе - 1 612.25 рублей. С разницей чуть больше 100 руб. составит посещение пиццерия на Юго-Востоке столицы. В ЦАО по данному критерию нет категорий заведений, которые опережают все районы.
При выборе булочная в ЦАО и ЗАО посетителю можно смело выбирать любую, так же как и бар.паб на Юго-Западе, Юго-Востоке и Северо-Западе Москвы. По этим категориям в указанных районах не попало ни одно заведение с низким рейтингом.
По категории заведений: самое распространенное заведение - кафе. Самое редкое - булочная.\
По количеству посадочных мест: медианное значение для всех групп заведений - 75 мест. Максимальные по уровню значения у ресторан (86), кофейня (80) и бар.паб (82), а минимальное - в булочной(50).\
По доле сетевых и несетевых заведений: все заведения разделились на сетевые (38.1%) и несетевые (61.9%). Наибольшая доля в группе сетевых - булочная (61%), затем кофейня и пиццерия (около 50%). В количественном выражении пропорция смещается: кафе (779), ресторан (730), кофейня (720).\
По названиям заведений: в ТОР-15 вошли все заведения из представителей сетей. Практически половина из них сосредоточена в сфере кофейня(46%). На последнем месте находится единственный представитель булочная с долей 3%.\
По административным районам: лидер - Центральный административный округ с долей 26.68%, опередил ближайший район более, чем в 2.5 раза. Меньше всего мест общественного питания на Северо-Западе столицы - менее 5%.\
По рейтингу категорий заведений: наиболее высокие оценки получает категория бар.паб (4.39), а самые низкие - заведения быстрое питание (4.05).\
По рейтингу заведений по районам: первое место - Центральный административный округ (4.38). Замкнул рейтинг Юго-Восточный административный округ (4.10).\
По наивысшей концентрации заведений в рамках 1 улицы: больше всего заведений на проспекте Мира, улице Профсоюзной, проспекте Вернадского и Ленинском проспекте. Чаще всего - это кафе, кофейня и ресторан.\
По улицам, где располагается только 1 заведение: на 30% улиц общественное питание представлено одним заведением. Чаще всего - это кафе (35%), ресторан или кофейня (около 20%). Реже всего на них можно встретить булочная (менее 2%).\
По медианному значению средних чеков: самый высокий медианный чек в Центральном и Западном административных округах - 1000 рублей. На последнем месте расположился Юго-Восточный - 450 рублей. Наивысшая волатильность по районам в категории булочная - от 200 до 962.5 рублей.\
По режиму работы: легче всего найти круглосуточное заведение в Центральном административном округе, это скорее всего будет кофейня, ресторан или бар.паб. В остальных районах этими заведениями могут быть кафе или быстрое питание.\
По распределению заведений с плохим рейтингом: плохим рейтингом считаю менее 4.1, чаще всего такой рейтинг можно встретить в категории кафе (717 заведений, 30.16%), но в процентном соотношении - быстрое питание (222 заведения, 36.82%). Чек в таких местах имеет широкий диапозон: от 200 руб. в булочная на Северо-Западе до 1 612.25 в быстрое питание в Восточном административном округе. При выборе булочная в ЦАО и ЗАО посетителю можно смело выбирать любую, так же как и бар.паб на Юго-Западе, Юго-Востоке и Северо-Западе Москвы. По этим категориям в указанных районах не попало ни одно заведение с низким рейтингом.
Для понимания того, осуществима ли мечта клиентов по открытию кофейни аналогичной «Central Perk», проведу ряд исследований по категории кофейня и визуализирую результаты.
# создам сводную таблицу
data_coffee_house = data.query('category == "кофейня"')
pvt_data_coffee_house = (
data_coffee_house
.groupby('district_acronym', as_index=False)
.agg(total_counts=('name', 'count'),
counts_chain=('chain', 'sum'),
around_the_clock=('is_24_7', 'sum'),
avg_rating=('rating', 'mean'),
coffe_price=('middle_coffee_cup', 'mean'),
middle_avg_bill=('middle_avg_bill', 'mean'),
avg_seats=('seats', 'median')
).round(2)
.sort_values(by='total_counts', ascending=False))
display(pvt_data_coffee_house)
print('Количество кофеен в Москве:', len(data_coffee_house))
print('Количество несетевых кофеен в Москве:', len(data_coffee_house) - data_coffee_house['chain'].sum())
print('Количество круглосуточных кофеен в Москве:', data_coffee_house['is_24_7'].sum())
print('Средний рейтинг кофеен в Москве:', data_coffee_house['rating'].mean().round(2))
print('Средний цена чашки капучино в Москве:', data_coffee_house['middle_coffee_cup'].mean().round())
print('Средний чек в кофейне в Москве:', data_coffee_house['middle_avg_bill'].mean().round())
print('Медианный показатель посадочных мест в кофейне в Москве:', data_coffee_house['seats'].median())
| district_acronym | total_counts | counts_chain | around_the_clock | avg_rating | coffe_price | middle_avg_bill | avg_seats | |
|---|---|---|---|---|---|---|---|---|
| 5 | ЦАО | 428 | 221 | 26 | 4.34 | 187.52 | 794.76 | 86.0 |
| 2 | САО | 193 | 97 | 5 | 4.29 | 165.79 | 495.74 | 66.0 |
| 3 | СВАО | 159 | 79 | 3 | 4.22 | 165.33 | 433.16 | 75.0 |
| 1 | ЗАО | 150 | 93 | 9 | 4.20 | 189.94 | 694.44 | 96.0 |
| 6 | ЮАО | 131 | 66 | 1 | 4.23 | 158.49 | 504.78 | 80.0 |
| 0 | ВАО | 105 | 51 | 5 | 4.28 | 174.02 | 486.11 | 55.0 |
| 8 | ЮЗАО | 96 | 50 | 7 | 4.28 | 184.18 | 381.82 | 64.5 |
| 7 | ЮВАО | 89 | 29 | 1 | 4.23 | 151.09 | 263.00 | 50.0 |
| 4 | СЗАО | 62 | 34 | 2 | 4.33 | 165.52 | 440.64 | 87.5 |
Количество кофеен в Москве: 1413 Количество несетевых кофеен в Москве: 693 Количество круглосуточных кофеен в Москве: 59 Средний рейтинг кофеен в Москве: 4.28 Средний цена чашки капучино в Москве: 175.0 Средний чек в кофейне в Москве: 614.0 Медианный показатель посадочных мест в кофейне в Москве: 80.0
Из этих данных можно сделать вывод, что:
# строю визуализацию
plt.figure(figsize=(14, 6))
ax = sns.barplot(
x='district_acronym',
y='total_counts',
data=pvt_data_coffee_house
)
# добавляю через цикл в каждый из столбцов его значение
for p in ax.patches:
ax.annotate(
format(int(p.get_height()), ',').replace(',', ' '),
(p.get_x() + p.get_width() / 2., p.get_height()),
ha = 'center', va = 'center',
xytext = (0, -14), color='white',
textcoords = 'offset points', size=16)
# указываю названия графика и оси Х, вывожу на экран
plt.title('Количество кофеен по округам', size=18)
plt.xlabel('Административный округ', size=14)
ax.get_yaxis().set_visible(False)
sns.despine(left=True)
plt.show()
# строю визуализацию
fig = px.scatter_mapbox(data_coffee_house, lat='lat', lon='lng',
color='chain', color_continuous_scale=["red", "blue"],
zoom=9, hover_name='name',
hover_data=['rating', 'middle_coffee_cup', 'middle_avg_bill', 'seats'],
labels={'lat' : 'широта', 'lng' : 'долгота',
'rating' : 'Рейтинг',
'chain' : 'Заведение относится к сети',
'middle_coffee_cup' : 'Стоимость чашки капучино',
'middle_avg_bill' : 'Средний чек',
'seats' : 'Количество мест'
})
fig.update_layout(
margin={"r": 0, "t": 0, "l": 0, "b": 0},
mapbox_style="carto-positron",
height=500,
)
fig.show()
География распространения кофеен не равномерна: в центре концентрация очень густая, по районам ситуация отличается между собой. Поскольку заказчик не боится конкуренции в интересующей его категории заведений общественного питания, стоит присмотреться к ТОР-15 улиц по количеству заведений всех категорий.
Оставлю на этих улицах только категорию кофейня и выведу карту. Дополнительно сформирую по ряду критериев сводную таблицу.
# подготавливаю датасет для визуализации
data_coffee_house_top_str = (
data_coffee_house.query('street in @top_str')
)
# строю визуализацию
fig = px.scatter_mapbox(data_coffee_house_top_str, lat='lat', lon='lng',
color='chain', color_continuous_scale=["red", "blue"],
zoom=9, hover_name='name',
hover_data=['rating', 'middle_coffee_cup', 'middle_avg_bill', 'seats'],
labels={'lat' : 'широта', 'lng' : 'долгота',
'rating' : 'Рейтинг',
'chain' : 'Заведение относится к сети',
'middle_coffee_cup' : 'Стоимость чашки капучино',
'middle_avg_bill' : 'Средний чек',
'seats' : 'Количество мест'
})
fig.update_layout(
margin={"r": 0, "t": 0, "l": 0, "b": 0},
mapbox_style="carto-positron",
height=500,
)
fig.show()
# создам сводную таблицу
pvt_data_ch = (
data_coffee_house_top_str
.groupby('street', as_index=False)
.agg(coffee_house=('name', 'count'),
chain=('chain', 'sum'),
coffe_price=('middle_coffee_cup', 'mean'),
middle_avg_bill=('middle_avg_bill', 'mean'),
avg_seats=('seats', 'median'))
)
pvt_data_ch = (
pvt_data_ch.merge(top_str_list, how='left', on='street')
)
pvt_data_ch['coffe_house_rate'] = (
pvt_data_ch['coffee_house'] /
pvt_data_ch['counts_per_str'] * 100
)
pvt_data_ch['unchain_rate'] = (
(pvt_data_ch['chain'] /
pvt_data_ch['coffee_house'] - 1) * 100).abs()
pvt_data_ch = (
pvt_data_ch.drop('chain', axis=1)
.sort_values(by='unchain_rate', ascending=False)
.round(2)
)
display(pvt_data_ch)
print('Средний цена чашки капучино на TOP-15 улиц по количеству заведений в Москве: {:.2f}'
.format(pvt_data_ch['coffe_price'].mean()))
print('Средний чек на TOP-15 улиц по количеству заведений в Москве: {:.2f}'
.format(pvt_data_ch['middle_avg_bill'].mean()))
print('Медианный показатель посадочных мест на TOP-15 улиц в кофейнях в Москве:', pvt_data_ch['avg_seats'].median())
print('Средний процент кофеен на TOP-15 улиц по количеству заведений в Москве: {:.2f}'
.format(pvt_data_ch['coffe_house_rate'].mean()))
print('Средний процент несетевых кофеен на TOP-15 улиц по количеству заведений в Москве: {:.2f}'
.format(pvt_data_ch['unchain_rate'].mean()))
| street | coffee_house | coffe_price | middle_avg_bill | avg_seats | counts_per_str | coffe_house_rate | unchain_rate | |
|---|---|---|---|---|---|---|---|---|
| 6 | Ленинский проспект | 23 | 224.33 | 416.67 | 98.0 | 107 | 21.50 | 65.22 |
| 4 | Ленинградский проспект | 25 | 187.55 | 350.00 | 150.0 | 95 | 26.32 | 56.00 |
| 0 | Варшавское шоссе | 14 | 180.25 | 716.67 | 180.0 | 76 | 18.42 | 50.00 |
| 10 | Пятницкая улица | 6 | 225.00 | 140.00 | 280.0 | 48 | 12.50 | 50.00 |
| 7 | Люблинская улица | 11 | 157.00 | 50.00 | 54.0 | 60 | 18.33 | 45.45 |
| 12 | проспект Мира | 36 | 187.89 | 770.00 | 111.5 | 183 | 19.67 | 41.67 |
| 9 | Профсоюзная улица | 18 | 161.83 | NaN | 80.5 | 122 | 14.75 | 33.33 |
| 2 | Каширское шоссе | 16 | 130.80 | 625.00 | 176.0 | 77 | 20.78 | 31.25 |
| 11 | проспект Вернадского | 16 | 188.75 | 225.00 | 160.0 | 108 | 14.81 | 31.25 |
| 13 | улица Вавилова | 10 | 160.00 | NaN | 320.0 | 55 | 18.18 | 30.00 |
| 1 | Дмитровское шоссе | 11 | 194.40 | 300.00 | 120.0 | 88 | 12.50 | 27.27 |
| 3 | Кутузовский проспект | 13 | 201.00 | 1150.00 | 96.0 | 54 | 24.07 | 23.08 |
| 5 | Ленинградское шоссе | 13 | 195.00 | 875.00 | 96.0 | 70 | 18.57 | 15.38 |
| 8 | МКАД | 4 | NaN | NaN | NaN | 65 | 6.15 | 0.00 |
| 14 | улица Миклухо-Маклая | 4 | 157.00 | NaN | 120.0 | 49 | 8.16 | 0.00 |
Средний цена чашки капучино на TOP-15 улиц по количеству заведений в Москве: 182.20 Средний чек на TOP-15 улиц по количеству заведений в Москве: 510.76 Медианный показатель посадочных мест на TOP-15 улиц в кофейнях в Москве: 120.0 Средний процент кофеен на TOP-15 улиц по количеству заведений в Москве: 16.98 Средний процент несетевых кофеен на TOP-15 улиц по количеству заведений в Москве: 33.33
Из этих данных можно сделать вывод, что:
Последние показатели использую для визуализации через столбчатые диаграммы.
# построение визуализации
plt.figure(figsize=(14, 6))
ax = sns.barplot(data=pvt_data_ch.sort_values(by='coffe_house_rate', ascending=False),
x='coffe_house_rate',
y='street',
palette='Set1')
# укажу названия графика и осей
plt.title('Средний процент кофеен на TOP-15 улиц по количеству заведений', size=18)
plt.xlabel('Доля кофеен', size=14)
plt.ylabel(' ')
# увеличу масштаб и выведу на экран
plt.axvline(x=pvt_data_ch['coffe_house_rate'].mean(), color='black', linestyle='--')
plt.grid(axis='x')
sns.despine(left=True)
plt.show()
# построение визуализации
plt.figure(figsize=(14, 6))
ax = sns.barplot(data=pvt_data_ch.sort_values(by='unchain_rate', ascending=False),
x='unchain_rate',
y='street',
palette='Set1')
# укажу названия графика и осей
plt.title('Средний процент несетевых кофеен на TOP-15 улиц по количеству заведений', size=18)
plt.xlabel('Доля кофеен', size=14)
plt.ylabel(' ')
# увеличу масштаб и выведу на экран
plt.axvline(x=pvt_data_ch['unchain_rate'].mean(), color='black', linestyle='--')
plt.grid(axis='x')
sns.despine(left=True)
plt.show()
coffe_house_rate = pvt_data_ch['coffe_house_rate'].mean().round(2)
unchain_rate = pvt_data_ch['unchain_rate'].mean().round(2)
pvt_data_ch.query('coffe_house_rate <= @coffe_house_rate and unchain_rate <= @unchain_rate')
| street | coffee_house | coffe_price | middle_avg_bill | avg_seats | counts_per_str | coffe_house_rate | unchain_rate | |
|---|---|---|---|---|---|---|---|---|
| 9 | Профсоюзная улица | 18 | 161.83 | NaN | 80.5 | 122 | 14.75 | 33.33 |
| 11 | проспект Вернадского | 16 | 188.75 | 225.0 | 160.0 | 108 | 14.81 | 31.25 |
| 1 | Дмитровское шоссе | 11 | 194.40 | 300.0 | 120.0 | 88 | 12.50 | 27.27 |
| 8 | МКАД | 4 | NaN | NaN | NaN | 65 | 6.15 | 0.00 |
| 14 | улица Миклухо-Маклая | 4 | 157.00 | NaN | 120.0 | 49 | 8.16 | 0.00 |
Подводя итог всех вычислений и расчетов выше, в выборку попадают 5 улиц, которые по среднему процента кофеен из общего числа заведений на улицу и среднему проценту несетевых кофеен из всех не превысили допустимый уровень наполненности категорией кофейня. Однако улицу МКАД исключу из рекомендуемых вариантов, поскольку длина улицы и количество заведений на ней несопоставимы. 3 из 4 оставшихся улиц располагаются в одном Юго-Западном административном округе (Профсоюзная улица, проспект Вернадского, улица Миклухо-Маклая), оставшееся Дмитровское шоссе в Северном округе. Эти районы стоит рассматривать для запуска нового проекта.
Район размещения: Юго-Западный или Северный административные округа;\ Ориентировочная локация: Профсоюзная улица, проспект Вернадского, улица Миклухо-Маклая (ЮЗАО), Дмитровское шоссе (САО);\ Количество посадочных мест: от 80 до 120;\ Средний чек заведения: от 400 до 600 рублей;\ Стоимость чашки капучино: от 165 до 185 рублей;\ График работы: не круглосуточно;\ Рейтинг заведения: не ниже 4.3.